為什麼要做這些?
想像你的後端是一間餐廳:
Recovery:廚房失火(panic)也不會整家店關門,會立刻滅火、顧客收到「抱歉我們 500 了」的通知。
Request ID:每張餐點單都有編號。客人抱怨「牛肉變成空氣」,你只要看單號就能查發生什麼事。
結構化日誌(JSON):不是一大坨文字,而是「有欄位」的資料:路徑、狀態碼、時間…像 Excel 一樣好搜。
API/網頁都用同一套錯誤格式,前端不必猜。
CORS 白名單:只讓你家的前端域名點餐,陌生網域別亂來。
小結:有了這五件事,你的服務就更像「可維運的產品」,不是只在你筆電上能跑的作品。😎
(Git 記得先開個新分支,畢竟這次動刀蠻大的: git switch -c feat/mw-errors)
這次的變動,主要在 middleware(中介層)、responder(回覆者) 和 errors(錯誤) 幾個新夥伴:
go-echo-blog/
├─ cmd/server/main.go # 掛中介層、CORS 白名單、自訂錯誤處理
├─ internal/http/
│ ├─ middleware/
│ │ ├─ reqid.go # Request ID
│ │ └─ logging.go # 結構化日誌(slog)
│ ├─ responder/responder.go # 統一成功/錯誤 JSON
│ ├─ errors/errors.go # 業務錯誤型別(AppError)
│ └─ http_error_handler.go # 自訂 HTTPErrorHandler(HTML/JSON)
├─ .env.example # 新增 CORS_ORIGINS
└─ ...
我們只讓這些網址的前端來點餐,安全又放心!
# 允許的前端網域(開發+正式)
CORS_ORIGINS=http://localhost:5173,https://your-frontend.example.com
這是給每個進來的請求一個 獨一無二的「單號」。
如果客人自己有帶單號(X-Request-ID Header),就用他的。
沒有?沒關係,我們自己用 uuid.NewString() 隨機生成一組。
這個單號會被塞進 Context 裡,讓後面所有的程式碼(例如日誌)都能取用。
package middleware
import (
"context"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
)
type ctxKey string
const RequestIDKey ctxKey = "request_id"
// 為每個請求產生 UUID,放進 Context 與回應 Header,日誌也會用到
func WithRequestID(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
id := c.Request().Header.Get("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
req := c.Request().WithContext(context.WithValue(c.Request().Context(), RequestIDKey, id))
c.SetRequest(req)
c.Response().Header().Set("X-Request-ID", id)
return next(c)
}
}
// 其他地方要取 ID(例如 logger)就用這個
func FromContext(ctx context.Context) string {
if v, ok := ctx.Value(RequestIDKey).(string); ok {
return v
}
return ""
}
有了 Request ID,我們的日誌(Log)就不再是雜亂無章的,而是結構化的!
我們使用 Go 內建的 log/slog,把它變成 JSON 格式,把 Request ID、路徑、狀態碼、花了多少時間...通通記下來。
梗:下次程式爆了,你只要拿著 Request ID 去日誌裡一搜,就像拿著單號去翻收銀機帳本,三秒鐘就知道哪個環節出了大問題!
package middleware
import (
"log/slog"
"time"
"github.com/labstack/echo/v4"
)
// 將每個請求以 JSON 欄位輸出(method/path/status/duration/request_id/...)
func WithSlog(logger *slog.Logger) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
err := next(c) // 先讓後面跑
status := c.Response().Status
req := c.Request()
id := FromContext(req.Context())
logger.Info("http_request",
slog.String("request_id", id),
slog.String("method", req.Method),
slog.String("path", req.URL.Path),
slog.Int("status", status),
slog.String("remote_ip", c.RealIP()),
slog.String("user_agent", req.UserAgent()),
slog.Duration("duration", time.Since(start)),
)
return err
}
}
}
後端回覆格式百百種?不行!我們要求服務生(responder)統一規格。
成功(JSONOK):一定有 request_id 和 data。
錯誤(JSONError):一定有 request_id、code(錯誤碼)、message(人看的訊息)、details(額外的詳細資料)。
package responder
import (
"net/http"
"github.com/labstack/echo/v4"
)
type ErrorPayload struct {
RequestID string `json:"request_id"`
Code string `json:"code"` // 例如 POST_SLUG_CONFLICT
Message string `json:"message"`
Details interface{} `json:"details,omitempty"`
}
type OKPayload struct {
RequestID string `json:"request_id"`
Data interface{} `json:"data"`
}
func JSONOK(c echo.Context, data interface{}) error {
id := c.Response().Header().Get("X-Request-ID")
return c.JSON(http.StatusOK, OKPayload{RequestID: id, Data: data})
}
func JSONError(c echo.Context, status int, code, message string, details any) error {
id := c.Response().Header().Get("X-Request-ID")
return c.JSON(status, ErrorPayload{
RequestID: id,
Code: code,
Message: message,
Details: details,
})
}
一般的錯誤是系統錯誤,但 「業務錯誤」 是指「你點的菜跟我的規定不符」。
例如:「文章 Slug 已經被用過」、「帳號密碼不正確」。
我們自己定義一個 AppError 型別,它可以帶:
Status: HTTP 狀態碼 (400, 404...)
Code: 獨特的錯誤碼(例如:POST_SLUG_CONFLICT)
Message: 錯誤訊息(中文或英文都可)
Details: 更多資訊(例如哪個 Slug 衝突了)
這樣在 Handler 裡丟出錯誤時,邏輯就超清晰!
package errors
import "net/http"
// 可預期的業務錯誤:會由 HTTPErrorHandler 長相統一地回給前端
type AppError struct {
Status int
Code string
Message string
Details any
}
func (e *AppError) Error() string { return e.Message }
func New(status int, code, message string, details any) *AppError {
return &AppError{Status: status, Code: code, Message: message, Details: details}
}
func NotFound(code, msg string, details any) *AppError { return New(http.StatusNotFound, code, msg, details) }
func BadRequest(code, msg string, details any) *AppError { return New(http.StatusBadRequest, code, msg, details) }
func Conflict(code, msg string, details any) *AppError { return New(http.StatusConflict, code, msg, details) }
這是最關鍵的一步!我們取代了 Echo 預設的錯誤處理,讓 ErrorHandler 成為強大的 「店經理」:
判斷錯誤型別:他會先檢查丟出來的錯誤,是不是我們自定義的 *AppError,還是 Echo 內建的 *echo.HTTPError。
判斷客人需求:經理會看客人(瀏覽器)的 Header,判斷他是要 JSON 格式(wantsJSON)還是要 HTML 網頁。
分流處理:
如果是 *AppError:就用 responder.JSONError 回 JSON,或用 c.Render 渲染錯誤網頁(例如 404.html)。
如果是 *echo.HTTPError:一樣分流回覆。
如果是 「未知的錯誤」(unhandled_error):通常是程式碼爆炸或資料庫連線斷了,經理會趕快 發出日誌警報(h.Logger.Error),然後回給客人一個友善的 500 錯誤:「系統忙線中,拜託再試一次」。
package httpx
import (
"errors"
"html/template"
"log/slog"
"net/http"
"strings"
appErr "your/module/internal/http/errors"
"your/module/internal/http/responder"
"github.com/labstack/echo/v4"
)
type ErrorHandler struct {
Logger *slog.Logger
Renderer echo.Renderer // 讓 HTML 404/500 可以用你的模板
}
func (h *ErrorHandler) Handle(err error, c echo.Context) {
if c.Response().Committed {
return // 已經回應過就不動
}
// 1) 我們自己定義的「業務錯誤」
var ae *appErr.AppError
if errors.As(err, &ae) {
if wantsJSON(c) {
_ = responder.JSONError(c, ae.Status, ae.Code, ae.Message, ae.Details)
return
}
_ = c.Render(ae.Status, pickErrorPage(ae.Status), map[string]any{
"title": "Error",
"message": ae.Message,
"code": ae.Code,
})
return
}
// 2) Echo 內建 HTTPError
var he *echo.HTTPError
if errors.As(err, &he) {
msg := "something went wrong"
if v, ok := he.Message.(string); ok {
msg = v
}
if wantsJSON(c) {
_ = responder.JSONError(c, he.Code, "HTTP_ERROR", msg, he.Internal)
return
}
_ = c.Render(he.Code, pickErrorPage(he.Code), map[string]any{
"title": "Error",
"message": template.HTMLEscapeString(msg),
"code": "HTTP_ERROR",
})
return
}
// 3) 其他未知錯誤 → 記錄並回 500
h.Logger.Error("unhandled_error", slog.String("err", err.Error()))
if wantsJSON(c) {
_ = responder.JSONError(c, http.StatusInternalServerError, "INTERNAL_ERROR", "系統忙線中,拜託再試一次", nil)
return
}
_ = c.Render(http.StatusInternalServerError, pickErrorPage(http.StatusInternalServerError), map[string]any{
"title": "Error",
"message": "系統忙線中,拜託再試一次",
"code": "INTERNAL_ERROR",
})
}
// 判斷要回 JSON 還是 HTML(Accept/Content-Type 或路徑以 /api/ 開頭)
func wantsJSON(c echo.Context) bool {
accept := c.Request().Header.Get("Accept")
ct := c.Request().Header.Get("Content-Type")
return strings.Contains(accept, echo.MIMEApplicationJSON) ||
strings.Contains(ct, echo.MIMEApplicationJSON) ||
strings.HasPrefix(c.Path(), "/api/")
}
func pickErrorPage(status int) string {
switch status {
case http.StatusNotFound:
return "pages/404.html"
default:
return "pages/error.html"
}
}
店經理名言:「絕不讓客人看到程式碼堆棧!所有未知的錯誤都要被記錄,並回覆統一的 500 訊息。」
在 main.go 裡,我們把所有新功能「開機啟動」!
package main
import (
"log/slog"
"net/http"
"os"
"strings"
httpx "your/module/internal/http"
appmw "your/module/internal/http/middleware"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
// 1) Recovery:避免 panic 把服務炸掉
e.Use(middleware.Recover())
// 2) JSON 結構化日誌(slog)+ Request ID
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
e.Use(appmw.WithRequestID)
e.Use(appmw.WithSlog(logger))
// 3) CORS 白名單:從環境變數讀
var origins []string
if env := os.Getenv("CORS_ORIGINS"); env != "" {
for _, s := range strings.Split(env, ",") {
origins = append(origins, strings.TrimSpace(s))
}
}
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: origins,
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions},
AllowHeaders: []string{"Content-Type", "Authorization", "X-CSRF-Token"},
}))
// 4) Session(沿用第 7 篇)
e.Use(session.Middleware(sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))))
// 5) 自訂錯誤處理:統一 HTML/JSON 回應
eh := &httpx.ErrorHandler{Logger: logger, Renderer: e.Renderer}
e.HTTPErrorHandler = eh.Handle
// 健檢
e.GET("/health", func(c echo.Context) error { return c.String(http.StatusOK, "ok") })
e.GET("/_ping", func(c echo.Context) error { return c.String(http.StatusOK, "pong") })
e.Logger.Fatal(e.Start(":1323"))
}
從此以後,在你的 Handler 函式裡,丟出錯誤就像丟垃圾一樣簡單又乾淨!
(A)丟出業務錯誤(自定義 AppError)
當你發現資料不符合業務邏輯時,直接回傳 appErr.Conflict 就好!
import (
appErr "your/module/internal/http/errors"
"net/http"
"github.com/labstack/echo/v4"
)
type PostsHandler struct{}
func (h *PostsHandler) Create(c echo.Context) error {
// 假設 slug 重複
return appErr.Conflict("POST_SLUG_CONFLICT", "slug 已被使用,換一個比較吉利", map[string]any{
"slug": c.FormValue("slug"),
})
}
(B)丟出標準 HTTP 錯誤
如果只是單純的「資料格式不對」這種標準錯誤,也可以繼續用 Echo 內建的:
return echo.NewHTTPError(http.StatusBadRequest, "資料格式不正確")
太棒了!這篇關於錯誤處理與中介層的硬核文章,我來幫你把它改成一篇超接地氣、國中生也能秒懂、充滿梗的 iThome 鐵人賽部落格文!
我們就用「後端餐廳」的比喻,把技術名詞變成大家都能會心一笑的日常用語。
第 9 篇|阿娘喂!後端餐廳的出包 SOP 啦!
主題:廚房失火?馬上救!帳單編號?一定有!客人抱怨?查得到!
目標:讓你的後端不再「土法煉鋼」(台語:Ló͘-hoat-liān-kǹg,意指老舊、粗糙的方法),出事時能看得懂、查得到、回得一致,成為一家五星級「不、會、爆」的餐廳!
發文日:2006-01-02(對,我就愛復古風!)
想像一下,你的後端系統就像一家 24 小時不打烊的「網際網路餐廳」:
技術名詞 餐廳情境(人話) 達成的目標
Recovery 廚房失火(Panic)立刻滅火 服務不會因為小錯誤整個當掉,客人只會收到「抱歉 500 了」的通知,而不是「服務器停止運作」的黑畫面。
Request ID 每張餐點單都有編號 客人說「我的牛肉麵怪怪的」,你說「請給我單號!」。從此「查水表」又快又準。
結構化日誌(JSON) 收銀機帳本變成 Excel 表格 記錄不是一坨亂七八糟的文字,而是有欄位、可以篩選的資料:路徑、狀態、時間... 像 Excel 一樣好搜、好分析。
統一錯誤回應 不管 API 還是網頁,出錯訊息都長一樣 前端工程師不必再「靠感覺」猜錯在哪,出包格式固定,世界和平!
CORS 白名單 只准熟客訂位,陌生人請滾! 只讓你家(設定好)的前端網域來點餐,防止奇怪的網站亂竄來要資料。
(Git 記得先開個新分支,畢竟這次動刀蠻大的: git switch -c feat/mw-errors)
這次的變動,主要在 middleware(中介層)、responder(回覆者) 和 errors(錯誤) 幾個新夥伴:
go-echo-blog/
├─ cmd/server/main.go # 總控台!掛中介層、CORS、自訂錯誤處理
├─ internal/http/
│ ├─ middleware/
│ │ ├─ reqid.go # 報單號!Request ID
│ │ └─ logging.go # 記帳本!結構化日誌 (slog)
│ ├─ responder/responder.go # 服務生!統一成功/錯誤 JSON 回應
│ ├─ errors/errors.go # 菜單!定義我們餐廳會出的「業務錯誤」
│ └─ http_error_handler.go # 店經理!自訂 HTTPErrorHandler(處理 HTML/JSON 兩種客人)
├─ .env.example # 新增 CORS_ORIGINS,指定我們的「熟客名單」
└─ ...
我們只讓這些網址的前端來點餐,安全又放心!
CORS_ORIGINS=http://localhost:5173,https://your-frontend.example.com
這是給每個進來的請求一個 獨一無二的「單號」。
如果客人自己有帶單號(X-Request-ID Header),就用他的。
沒有?沒關係,我們自己用 uuid.NewString() 隨機生成一組。
這個單號會被塞進 Context 裡,讓後面所有的程式碼(例如日誌)都能取用。
(程式碼跟原文一樣,重點在理解:「 Request ID,追蹤錯誤的命脈!」)
有了 Request ID,我們的日誌(Log)就不再是雜亂無章的,而是結構化的!
我們使用 Go 內建的 log/slog,把它變成 JSON 格式,把 Request ID、路徑、狀態碼、花了多少時間...通通記下來。
梗:下次程式爆了,你只要拿著 Request ID 去日誌裡一搜,就像拿著單號去翻收銀機帳本,三秒鐘就知道哪個環節出了大問題!
(程式碼跟原文一樣,重點是把 Request ID 塞進 slog.String("request_id", id))
後端回覆格式百百種?不行!我們要求服務生(responder)統一規格。
成功(JSONOK):一定有 request_id 和 data。
錯誤(JSONError):一定有 request_id、code(錯誤碼)、message(人看的訊息)、details(額外的詳細資料)。
Go
// 錯誤格式,出包時長這樣,前端一看就懂!
type ErrorPayload struct {
RequestID string json:"request_id"
// 單號
Code string json:"code"
// 錯誤代碼,給程式看的
Message string json:"message"
// 錯誤訊息,給人看的
Details interface{} json:"details,omitempty"
// 其他資料
}
// ... 還有成功格式 OKPayload
一般的錯誤是系統錯誤,但 「業務錯誤」 是指「你點的菜跟我的規定不符」。
例如:「文章 Slug 已經被用過」、「帳號密碼不正確」。
我們自己定義一個 AppError 型別,它可以帶:
Status: HTTP 狀態碼 (400, 404...)
Code: 獨特的錯誤碼(例如:POST_SLUG_CONFLICT)
Message: 錯誤訊息(中文或英文都可)
Details: 更多資訊(例如哪個 Slug 衝突了)
這樣在 Handler 裡丟出錯誤時,邏輯就超清晰!
Go
// 這是我們自定義的「業務錯誤」結構
type AppError struct {
Status int
Code string
Message string
Details any
}
func NotFound(code, msg string, details any) *AppError {
return New(http.StatusNotFound, code, msg, details)
}
// ... 還有 BadRequest, Conflict 等等方便的建構函式
這是最關鍵的一步!我們取代了 Echo 預設的錯誤處理,讓 ErrorHandler 成為強大的 「店經理」:
判斷錯誤型別:他會先檢查丟出來的錯誤,是不是我們自定義的 *AppError,還是 Echo 內建的 *echo.HTTPError。
判斷客人需求:經理會看客人(瀏覽器)的 Header,判斷他是要 JSON 格式(wantsJSON)還是要 HTML 網頁。
分流處理:
如果是 *AppError:就用 responder.JSONError 回 JSON,或用 c.Render 渲染錯誤網頁(例如 404.html)。
如果是 *echo.HTTPError:一樣分流回覆。
如果是 「未知的錯誤」(unhandled_error):通常是程式碼爆炸或資料庫連線斷了,經理會趕快 發出日誌警報(h.Logger.Error),然後回給客人一個友善的 500 錯誤:「系統忙線中,拜託再試一次」。
店經理箴言:「絕不讓客人看到程式碼堆棧!所有未知的錯誤都要被記錄,並回覆統一的 500 訊息。」
在 main.go 裡,我們把所有新功能「開機啟動」!
Go
func main() {
e := echo.New()
// 1. Recovery:廚房失火?自動滅火!
e.Use(middleware.Recover())
// 2. Request ID & 結構化日誌
logger := slog.New(slog.NewJSONHandler(os.Stdout, ...))
e.Use(appmw.WithRequestID)
e.Use(appmw.WithSlog(logger))
// 3. CORS 白名單:只讓熟客點餐
// ... 讀取 CORS_ORIGINS 環境變數並設定 CORS 中介層
// 4. 掛上店經理:接管所有錯誤!
eh := &httpx.ErrorHandler{Logger: logger, Renderer: e.Renderer}
e.HTTPErrorHandler = eh.Handle // 把 Echo 預設的換成我們自訂的 Handle 函式!
// ... 啟動伺服器
}
從此以後,在你的 Handler 函式裡,丟出錯誤就像丟垃圾一樣簡單又乾淨!
(A)丟出業務錯誤(自定義 AppError)
當你發現資料不符合業務邏輯時,直接回傳 appErr.Conflict 就好!
Go
func (h *PostsHandler) Create(c echo.Context) error {
// 發現 Slug 已經被用過了!
return appErr.Conflict("POST_SLUG_CONFLICT", "slug 已被使用,換一個比較吉利",
map[string]any{
"slug": c.FormValue("slug"), // 把衝突的 slug 也回傳給前端
})
}
// 店經理 (ErrorHandler) 會接手這個 AppError,並回覆 409 Conflict 的 JSON 錯誤格式。
(B)丟出標準 HTTP 錯誤
如果只是單純的「資料格式不對」這種標準錯誤,也可以繼續用 Echo 內建的:
Go
return echo.NewHTTPError(http.StatusBadRequest, "資料格式不正確")
// 店經理也會接手,並把它包裝成統一的 JSON 錯誤格式。
成功:
{"request_id":"8a9c-..." , "data":{"id":123,"title":"哈囉世界"}}
錯誤:
{
"request_id":"8a9c-...",
"code":"POST_SLUG_CONFLICT",
"message":"slug已被使用,換一個比較吉利",
"details":{
"slug":"hello-world"
}
}
常見坑(快速排雷)
CORS 卡住:正式環境請把 CORS_ORIGINS 設成你的前端網域,不要全開。
Request ID 沒印出來:確認 WithRequestID 有掛在 WithSlog 之前或一起掛上。
HTML 404 沒吃到模板:檢查 Renderer 是否有設好、模板路徑是否吻合 pickErrorPage。
判斷 JSON 不準:wantsJSON 用 strings.HasPrefix(path, "/api/") + Accept/Content-Type,避免只比對單一 header
你已經升級了 🔧
現在你的服務遇到錯誤時:
不會直接倒(Recovery);
能用單號追查(Request ID);
log 友善(slog 結構化);
API/HTML 錯誤長相統一;
CORS 更安全。
這套就是後端的「安全帶+黑盒子」。遇到事故不慌,事後也查得到。👏